Files
pack/core/providers/php/php.go
T
zeekay 7844a60aff refactor(pack): modernize to github.com/hanzoai/pack and remove upstream branding
- Migrate module path to github.com/hanzoai/pack across all Go packages
- Remove legacy upstream docs and competitor brand references
- Modernize LLM.md and mise.toml for pristine Hanzo Pack builder
2026-09-04 00:40:42 -07:00

468 lines
13 KiB
Go

package php
import (
"fmt"
"net/http"
"strconv"
"strings"
_ "embed"
"github.com/hanzoai/pack/core/generate"
"github.com/hanzoai/pack/core/plan"
"github.com/hanzoai/pack/core/providers/node"
"github.com/stretchr/objx"
)
const (
DEFAULT_PHP_VERSION = "8.4"
DefaultCaddyfilePath = "/Caddyfile"
COMPOSER_CACHE_DIR = "/opt/cache/composer"
)
//go:embed Caddyfile
var caddyfileTemplate string
//go:embed start-container.sh
var startContainerScript string
//go:embed php.ini
var phpIniTemplate string
type PhpProvider struct{}
func (p *PhpProvider) Name() string {
return "php"
}
func (p *PhpProvider) Detect(ctx *generate.GenerateContext) (bool, error) {
return ctx.App.HasFile("index.php") ||
ctx.App.HasFile("composer.json"), nil
}
func (p *PhpProvider) Initialize(ctx *generate.GenerateContext) error {
return nil
}
func (p *PhpProvider) Plan(ctx *generate.GenerateContext) error {
phpImageStep, err := p.phpImagePackage(ctx)
if err != nil {
return err
}
configFiles, err := p.getConfigFiles(ctx)
if err != nil {
return fmt.Errorf("failed to get config files: %w", err)
}
isLaravel := p.usesLaravel(ctx)
prepare := ctx.NewCommandStep("prepare")
prepare.AddInput(plan.NewStepLayer(phpImageStep.Name()))
p.Prepare(ctx, prepare, configFiles)
extensions := ctx.NewCommandStep("extensions")
extensions.AddInput(plan.NewStepLayer(prepare.Name()))
p.InstallExtensions(ctx, extensions)
composer := ctx.NewCommandStep("install:composer")
composer.AddInput(plan.NewStepLayer(extensions.Name()))
p.InstallCompose(ctx, composer)
// Node (if necessary)
nodeProvider := node.NodeProvider{}
isNode, err := nodeProvider.Detect(ctx)
if err != nil {
return err
}
if isLaravel {
ctx.Logger.LogInfo("Found Laravel app")
}
ctx.Metadata.SetBool("phpLaravel", isLaravel)
if isNode {
err = p.DeployWithNode(ctx, nodeProvider, composer, isLaravel)
if err != nil {
return err
}
} else {
// A manual build command will go here
build := ctx.NewCommandStep("build")
build.AddInput(plan.NewStepLayer(composer.Name()))
build.AddInput(plan.NewLocalLayer())
ctx.Deploy.Base = plan.NewStepLayer(build.Name())
p.ConditionallyIncludeMise(ctx)
}
ctx.Deploy.StartCmd = "/start-container.sh"
return nil
}
func (p *PhpProvider) Prepare(ctx *generate.GenerateContext, prepare *generate.CommandStepBuilder, configFiles *ConfigFiles) {
if configFiles.Caddyfile.Filename != "" {
ctx.Logger.LogInfo("Using custom Caddyfile: %s", configFiles.Caddyfile.Filename)
}
if configFiles.PhpIni.Filename != "" {
ctx.Logger.LogInfo("Using custom php.ini: %s", configFiles.PhpIni.Filename)
}
if configFiles.StartContainerScript.Filename != "" {
ctx.Logger.LogInfo("Using custom start-container.sh: %s", configFiles.StartContainerScript.Filename)
}
prepare.Assets["Caddyfile"] = configFiles.Caddyfile.Contents
prepare.Assets["php.ini"] = configFiles.PhpIni.Contents
prepare.Assets["start-container.sh"] = configFiles.StartContainerScript.Contents
prepare.AddEnvVars(map[string]string{
"APP_ENV": "production",
"APP_DEBUG": "false",
"APP_LOCALE": "en",
"LOG_CHANNEL": "stderr",
"LOG_LEVEL": "debug",
"SERVER_NAME": ":80",
"PHP_INI_DIR": "/usr/local/etc/php",
"OCTANE_SERVER": "frankenphp",
"IS_LARAVEL": strconv.FormatBool(p.usesLaravel(ctx)),
})
prepare.AddCommands([]plan.Command{
plan.NewExecCommand("mkdir -p /usr/local/etc/php/conf.d"),
plan.NewExecCommand("mkdir -p /conf.d/"),
plan.NewFileCommand("/usr/local/etc/php/conf.d/php.ini", "php.ini"),
plan.NewFileCommand(DefaultCaddyfilePath, "Caddyfile"),
plan.NewFileCommand("/start-container.sh", "start-container.sh", plan.FileOptions{
CustomName: "create start container script",
Mode: 0755,
}),
})
prepare.Secrets = []string{}
}
func (p *PhpProvider) InstallExtensions(ctx *generate.GenerateContext, extensions *generate.CommandStepBuilder) {
phpExtensions := p.getPhpExtensions(ctx)
if len(phpExtensions) > 0 {
extensions.AddCommands([]plan.Command{
plan.NewExecCommand(fmt.Sprintf("install-php-extensions %s", strings.Join(phpExtensions, " "))),
})
extensions.Caches = append(extensions.Caches, ctx.Caches.GetAptCaches()...)
}
extensions.Secrets = []string{}
}
func (p *PhpProvider) InstallCompose(ctx *generate.GenerateContext, composer *generate.CommandStepBuilder) {
composer.Secrets = []string{}
composer.UseSecretsWithPrefixes([]string{"COMPOSER", "PHP"})
composer.AddVariables(map[string]string{
"COMPOSER_FUND": "0",
"COMPOSER_CACHE_DIR": COMPOSER_CACHE_DIR,
})
if _, err := p.readComposerJson(ctx); err == nil {
composer.AddCache(ctx.Caches.AddCache("composer", COMPOSER_CACHE_DIR))
composerFiles := p.ComposerSupportingFiles(ctx)
// Copy composer from the composer image
composer.AddCommand(plan.CopyCommand{Image: "composer:latest", Src: "/usr/bin/composer", Dest: "/usr/bin/composer"})
for _, file := range composerFiles {
composer.AddCommand(plan.NewCopyCommand(file))
}
composer.AddCommands([]plan.Command{
plan.NewExecCommand("composer install --optimize-autoloader --no-scripts --no-interaction"),
})
}
}
func (p *PhpProvider) DeployWithNode(ctx *generate.GenerateContext, nodeProvider node.NodeProvider, composer *generate.CommandStepBuilder, isLaravel bool) error {
err := nodeProvider.Initialize(ctx)
if err != nil {
return err
}
ctx.Logger.LogInfo("Installing Node")
miseStep := ctx.GetMiseStepBuilder()
nodeProvider.InstallMisePackages(ctx, miseStep)
install := ctx.NewCommandStep("install:node")
install.AddInput(plan.NewStepLayer(miseStep.Name()))
nodeProvider.InstallNodeDeps(ctx, install)
prune := ctx.NewCommandStep("prune:node")
prune.AddInput(plan.NewStepLayer(install.Name()))
nodeProvider.PruneNodeDeps(ctx, prune)
build := ctx.NewCommandStep("build")
build.Inputs = []plan.Layer{
plan.NewStepLayer(composer.Name()),
plan.NewStepLayer(install.Name(), plan.Filter{
Include: append([]string{"."}, miseStep.GetOutputPaths()...),
}),
}
nodeProvider.Build(ctx, build)
if isLaravel {
build.AddCommands([]plan.Command{
plan.NewExecShellCommand("mkdir -p storage/framework/{sessions,views,cache,testing} storage/logs bootstrap/cache && chmod -R a+rw storage"),
// config values like APP_KEY are resolved and baked into bootstrap/cache/config.php. Runtime env vars are ignored
// for cached configs, leading to MissingAppKeyException if APP_KEY was unset at cache time.
plan.NewExecCommand("php artisan config:cache"),
plan.NewExecCommand("php artisan event:cache"),
plan.NewExecCommand("php artisan route:cache"),
plan.NewExecCommand("php artisan view:cache"),
})
}
ctx.Deploy.Base = plan.NewStepLayer(composer.Name())
p.ConditionallyIncludeMise(ctx)
ctx.Deploy.AddInputs([]plan.Layer{
plan.NewStepLayer(prune.Name(), plan.Filter{
Include: []string{"/app/node_modules"},
}),
plan.NewStepLayer(build.Name(), plan.Filter{
Include: []string{"."},
Exclude: []string{"node_modules"},
}),
})
return nil
}
// Include mise and packages in the final image if the user has specified any additional packages
func (p *PhpProvider) ConditionallyIncludeMise(ctx *generate.GenerateContext) {
if len(ctx.GetMiseStepBuilder().MisePackages) >= 1 {
ctx.Deploy.AddInputs([]plan.Layer{
ctx.GetMiseStepBuilder().GetLayer(),
})
}
}
func (p *PhpProvider) ComposerSupportingFiles(ctx *generate.GenerateContext) []string {
patterns := []string{
"**/composer.json",
"**/composer.lock",
"artisan",
}
var allFiles []string
for _, pattern := range patterns {
files, err := ctx.App.FindFiles(pattern)
if err != nil {
continue
}
allFiles = append(allFiles, files...)
dirs, err := ctx.App.FindDirectories(pattern)
if err != nil {
continue
}
allFiles = append(allFiles, dirs...)
}
return allFiles
}
func (p *PhpProvider) getPhpExtensions(ctx *generate.GenerateContext) []string {
extensions := []string{}
composerJson, err := p.readComposerJson(ctx)
if err != nil {
return extensions
}
if require, ok := composerJson["require"].(map[string]any); ok {
for ext := range require {
if after, ok0 := strings.CutPrefix(ext, "ext-"); ok0 {
extensions = append(extensions, after)
}
}
}
if extensionsVar, _ := ctx.Env.GetConfigVariable("PHP_EXTENSIONS"); extensionsVar != "" {
extensions = append(extensions, strings.FieldsFunc(extensionsVar, func(r rune) bool {
return r == ',' || r == ' '
})...)
}
if p.usesLaravel(ctx) {
// https://laravel.com/docs/12.x/deployment#server-requirements
extensions = append(extensions,
"ctype",
"curl",
"dom",
"fileinfo",
"filter",
"hash",
"mbstring",
"openssl",
"pcre",
"pdo",
"session",
"tokenizer",
"xml")
}
if dbConnection := ctx.Env.GetVariable("DB_CONNECTION"); dbConnection != "" {
switch dbConnection {
case "mysql":
extensions = append(extensions, "pdo_mysql")
case "pgsql":
extensions = append(extensions, "pdo_pgsql")
}
}
if p.needsRedisExtension(ctx, composerJson) {
extensions = append(extensions, "redis")
}
return extensions
}
func (p *PhpProvider) needsRedisExtension(ctx *generate.GenerateContext, composerJson map[string]any) bool {
// Check if Redis is explicitly mentioned in environment variables
redisHost := ctx.Env.GetVariable("REDIS_HOST")
redisUrl := ctx.Env.GetVariable("REDIS_URL")
cacheDriver := ctx.Env.GetVariable("CACHE_DRIVER")
sessionDriver := ctx.Env.GetVariable("SESSION_DRIVER")
queueConnection := ctx.Env.GetVariable("QUEUE_CONNECTION")
if redisHost != "" || redisUrl != "" ||
cacheDriver == "redis" || sessionDriver == "redis" || queueConnection == "redis" {
return true
}
// Check for Redis packages in composer.json
if require, ok := composerJson["require"].(map[string]any); ok {
for pkg := range require {
if strings.Contains(pkg, "redis") ||
strings.Contains(pkg, "predis") {
return true
}
}
}
return false
}
func (p *PhpProvider) usesLaravel(ctx *generate.GenerateContext) bool {
return ctx.App.HasFile("artisan")
}
type ConfigFiles struct {
Caddyfile *generate.TemplateFileResult
StartContainerScript *generate.TemplateFileResult
PhpIni *generate.TemplateFileResult
}
func (p *PhpProvider) getConfigFiles(ctx *generate.GenerateContext) (*ConfigFiles, error) {
phpRootDir := "/app"
if variable := ctx.Env.GetVariable("RAILPACK_PHP_ROOT_DIR"); variable != "" {
phpRootDir = variable
} else if p.usesLaravel(ctx) {
phpRootDir = "/app/public"
}
data := map[string]any{
"RAILPACK_PHP_ROOT_DIR": phpRootDir,
"IS_LARAVEL": p.usesLaravel(ctx),
}
caddyfile, err := ctx.TemplateFiles([]string{"Caddyfile"}, caddyfileTemplate, data)
if err != nil {
return nil, err
}
startContainerScript, err := ctx.TemplateFiles([]string{"start-container.sh"}, startContainerScript, data)
if err != nil {
return nil, err
}
phpIni, err := ctx.TemplateFiles([]string{"php.ini"}, phpIniTemplate, data)
if err != nil {
return nil, err
}
return &ConfigFiles{
Caddyfile: caddyfile,
StartContainerScript: startContainerScript,
PhpIni: phpIni,
}, nil
}
func (p *PhpProvider) phpImagePackage(ctx *generate.GenerateContext) (*generate.ImageStepBuilder, error) {
imageStep := ctx.NewImageStep("packages:image", func(options *generate.BuildStepOptions) string {
if phpVersion, ok := options.ResolvedPackages["php"]; ok && phpVersion.ResolvedVersion != nil {
return getPhpImage(*phpVersion.ResolvedVersion)
}
// Return the default if we were not able to resolve the version
return getPhpImage(DEFAULT_PHP_VERSION)
})
imageStep.AptPackages = append(imageStep.AptPackages, "git", "zip", "unzip", "ca-certificates")
// Include both build and runtime apt packages since we don't have a separate runtime image
imageStep.AptPackages = append(imageStep.AptPackages, ctx.Config.BuildAptPackages...)
imageStep.AptPackages = append(imageStep.AptPackages, ctx.Config.Deploy.AptPackages...)
php := imageStep.Default("php", DEFAULT_PHP_VERSION)
// Read composer.json to get the PHP version
if composerJson, err := p.readComposerJson(ctx); err == nil {
phpVersion := objx.New(composerJson).Get("require.php")
if phpVersion.IsStr() {
if after, ok := strings.CutPrefix(phpVersion.Str(), "^"); ok {
imageStep.Version(php, after, "composer.json > require > php")
} else {
imageStep.Version(php, phpVersion.Str(), "composer.json > require > php")
}
}
}
// Ensure that the version is available on Docker Hub
imageStep.SetVersionAvailable(php, func(version string) bool {
image := getPhpImage(version)
// dunglas/frankenphp:php8.4.3-bookworm -> [dunglas, frankenphp, php8.4.3-bookworm]
parts := strings.Split(image, ":")
repository := parts[0] // dunglas/frankenphp
tag := parts[1] // php8.4.3-bookworm
url := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/tags/%s", repository, tag)
resp, err := http.Get(url)
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode == http.StatusOK
})
return imageStep, nil
}
func getPhpImage(phpVersion string) string {
return fmt.Sprintf("dunglas/frankenphp:php%s-bookworm", phpVersion)
}
func (p *PhpProvider) readComposerJson(ctx *generate.GenerateContext) (map[string]any, error) {
var composerJson map[string]any
err := ctx.App.ReadJSON("composer.json", &composerJson)
if err != nil {
return nil, err
}
return composerJson, nil
}
func (p *PhpProvider) CleansePlan(buildPlan *plan.BuildPlan) {}
func (p *PhpProvider) StartCommandHelp() string {
return ""
}