package php import ( "encoding/json" "os" "path/filepath" "sort" "strings" "github.com/host-uk/core/pkg/cli" ) // DockerfileConfig holds configuration for generating a Dockerfile. type DockerfileConfig struct { // PHPVersion is the PHP version to use (default: "8.3"). PHPVersion string // BaseImage is the base Docker image (default: "dunglas/frankenphp"). BaseImage string // PHPExtensions is the list of PHP extensions to install. PHPExtensions []string // HasAssets indicates if the project has frontend assets to build. HasAssets bool // PackageManager is the Node.js package manager (npm, pnpm, yarn, bun). PackageManager string // IsLaravel indicates if this is a Laravel project. IsLaravel bool // HasOctane indicates if Laravel Octane is installed. HasOctane bool // UseAlpine uses the Alpine-based image (smaller). UseAlpine bool } // GenerateDockerfile generates a Dockerfile for a PHP/Laravel project. // It auto-detects dependencies from composer.json and project structure. func GenerateDockerfile(dir string) (string, error) { config, err := DetectDockerfileConfig(dir) if err != nil { return "", err } return GenerateDockerfileFromConfig(config), nil } // DetectDockerfileConfig detects configuration from project files. func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) { config := &DockerfileConfig{ PHPVersion: "8.3", BaseImage: "dunglas/frankenphp", UseAlpine: true, } // Read composer.json composerPath := filepath.Join(dir, "composer.json") composerData, err := os.ReadFile(composerPath) if err != nil { return nil, cli.WrapVerb(err, "read", "composer.json") } var composer ComposerJSON if err := json.Unmarshal(composerData, &composer); err != nil { return nil, cli.WrapVerb(err, "parse", "composer.json") } // Detect PHP version from composer.json if phpVersion, ok := composer.Require["php"]; ok { config.PHPVersion = extractPHPVersion(phpVersion) } // Detect if Laravel if _, ok := composer.Require["laravel/framework"]; ok { config.IsLaravel = true } // Detect if Octane if _, ok := composer.Require["laravel/octane"]; ok { config.HasOctane = true } // Detect required PHP extensions config.PHPExtensions = detectPHPExtensions(composer) // Detect frontend assets config.HasAssets = hasNodeAssets(dir) if config.HasAssets { config.PackageManager = DetectPackageManager(dir) } return config, nil } // GenerateDockerfileFromConfig generates a Dockerfile from the given configuration. func GenerateDockerfileFromConfig(config *DockerfileConfig) string { var sb strings.Builder // Base image baseTag := cli.Sprintf("latest-php%s", config.PHPVersion) if config.UseAlpine { baseTag += "-alpine" } sb.WriteString("# Auto-generated Dockerfile for FrankenPHP\n") sb.WriteString("# Generated by Core Framework\n\n") // Multi-stage build for smaller images if config.HasAssets { // Frontend build stage sb.WriteString("# Stage 1: Build frontend assets\n") sb.WriteString("FROM node:20-alpine AS frontend\n\n") sb.WriteString("WORKDIR /app\n\n") // Copy package files based on package manager switch config.PackageManager { case "pnpm": sb.WriteString("RUN corepack enable && corepack prepare pnpm@latest --activate\n\n") sb.WriteString("COPY package.json pnpm-lock.yaml ./\n") sb.WriteString("RUN pnpm install --frozen-lockfile\n\n") case "yarn": sb.WriteString("COPY package.json yarn.lock ./\n") sb.WriteString("RUN yarn install --frozen-lockfile\n\n") case "bun": sb.WriteString("RUN npm install -g bun\n\n") sb.WriteString("COPY package.json bun.lockb ./\n") sb.WriteString("RUN bun install --frozen-lockfile\n\n") default: // npm sb.WriteString("COPY package.json package-lock.json ./\n") sb.WriteString("RUN npm ci\n\n") } sb.WriteString("COPY . .\n\n") // Build command switch config.PackageManager { case "pnpm": sb.WriteString("RUN pnpm run build\n\n") case "yarn": sb.WriteString("RUN yarn build\n\n") case "bun": sb.WriteString("RUN bun run build\n\n") default: sb.WriteString("RUN npm run build\n\n") } } // PHP build stage stageNum := 2 if config.HasAssets { sb.WriteString(cli.Sprintf("# Stage %d: PHP application\n", stageNum)) } sb.WriteString(cli.Sprintf("FROM %s:%s AS app\n\n", config.BaseImage, baseTag)) sb.WriteString("WORKDIR /app\n\n") // Install PHP extensions if needed if len(config.PHPExtensions) > 0 { sb.WriteString("# Install PHP extensions\n") sb.WriteString(cli.Sprintf("RUN install-php-extensions %s\n\n", strings.Join(config.PHPExtensions, " "))) } // Copy composer files first for better caching sb.WriteString("# Copy composer files\n") sb.WriteString("COPY composer.json composer.lock ./\n\n") // Install composer dependencies sb.WriteString("# Install PHP dependencies\n") sb.WriteString("RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction\n\n") // Copy application code sb.WriteString("# Copy application code\n") sb.WriteString("COPY . .\n\n") // Run post-install scripts sb.WriteString("# Run composer scripts\n") sb.WriteString("RUN composer dump-autoload --optimize\n\n") // Copy frontend assets if built if config.HasAssets { sb.WriteString("# Copy built frontend assets\n") sb.WriteString("COPY --from=frontend /app/public/build public/build\n\n") } // Laravel-specific setup if config.IsLaravel { sb.WriteString("# Laravel setup\n") sb.WriteString("RUN php artisan config:cache \\\n") sb.WriteString(" && php artisan route:cache \\\n") sb.WriteString(" && php artisan view:cache\n\n") // Set permissions sb.WriteString("# Set permissions for Laravel\n") sb.WriteString("RUN chown -R www-data:www-data storage bootstrap/cache \\\n") sb.WriteString(" && chmod -R 775 storage bootstrap/cache\n\n") } // Expose ports sb.WriteString("# Expose ports\n") sb.WriteString("EXPOSE 80 443\n\n") // Health check sb.WriteString("# Health check\n") sb.WriteString("HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n") sb.WriteString(" CMD curl -f http://localhost/up || exit 1\n\n") // Start command sb.WriteString("# Start FrankenPHP\n") if config.HasOctane { sb.WriteString("CMD [\"php\", \"artisan\", \"octane:start\", \"--server=frankenphp\", \"--host=0.0.0.0\", \"--port=80\"]\n") } else { sb.WriteString("CMD [\"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\"]\n") } return sb.String() } // ComposerJSON represents the structure of composer.json. type ComposerJSON struct { Name string `json:"name"` Require map[string]string `json:"require"` RequireDev map[string]string `json:"require-dev"` } // detectPHPExtensions detects required PHP extensions from composer.json. func detectPHPExtensions(composer ComposerJSON) []string { extensionMap := make(map[string]bool) // Check for common packages and their required extensions packageExtensions := map[string][]string{ // Database "doctrine/dbal": {"pdo_mysql", "pdo_pgsql"}, "illuminate/database": {"pdo_mysql"}, "laravel/framework": {"pdo_mysql", "bcmath", "ctype", "fileinfo", "mbstring", "openssl", "tokenizer", "xml"}, "mongodb/mongodb": {"mongodb"}, "predis/predis": {"redis"}, "phpredis/phpredis": {"redis"}, "laravel/horizon": {"redis", "pcntl"}, "aws/aws-sdk-php": {"curl"}, "intervention/image": {"gd"}, "intervention/image-laravel": {"gd"}, "spatie/image": {"gd"}, "league/flysystem-aws-s3-v3": {"curl"}, "guzzlehttp/guzzle": {"curl"}, "nelmio/cors-bundle": {}, // Queues "laravel/reverb": {"pcntl"}, "php-amqplib/php-amqplib": {"sockets"}, // Misc "moneyphp/money": {"bcmath", "intl"}, "symfony/intl": {"intl"}, "nesbot/carbon": {"intl"}, "spatie/laravel-medialibrary": {"exif", "gd"}, } // Check all require and require-dev dependencies allDeps := make(map[string]string) for pkg, ver := range composer.Require { allDeps[pkg] = ver } for pkg, ver := range composer.RequireDev { allDeps[pkg] = ver } // Find required extensions for pkg := range allDeps { if exts, ok := packageExtensions[pkg]; ok { for _, ext := range exts { extensionMap[ext] = true } } // Check for direct ext- requirements if strings.HasPrefix(pkg, "ext-") { ext := strings.TrimPrefix(pkg, "ext-") // Skip extensions that are built into PHP builtIn := map[string]bool{ "json": true, "ctype": true, "iconv": true, "session": true, "simplexml": true, "pdo": true, "xml": true, "tokenizer": true, } if !builtIn[ext] { extensionMap[ext] = true } } } // Convert to sorted slice extensions := make([]string, 0, len(extensionMap)) for ext := range extensionMap { extensions = append(extensions, ext) } sort.Strings(extensions) return extensions } // extractPHPVersion extracts a clean PHP version from a composer constraint. func extractPHPVersion(constraint string) string { // Handle common formats: ^8.2, >=8.2, 8.2.*, ~8.2 constraint = strings.TrimLeft(constraint, "^>=~") constraint = strings.TrimRight(constraint, ".*") // Extract major.minor parts := strings.Split(constraint, ".") if len(parts) >= 2 { return parts[0] + "." + parts[1] } if len(parts) == 1 { return parts[0] + ".0" } return "8.3" // default } // hasNodeAssets checks if the project has frontend assets. func hasNodeAssets(dir string) bool { packageJSON := filepath.Join(dir, "package.json") if _, err := os.Stat(packageJSON); err != nil { return false } // Check for build script in package.json data, err := os.ReadFile(packageJSON) if err != nil { return false } var pkg struct { Scripts map[string]string `json:"scripts"` } if err := json.Unmarshal(data, &pkg); err != nil { return false } // Check if there's a build script _, hasBuild := pkg.Scripts["build"] return hasBuild } // GenerateDockerignore generates a .dockerignore file content for PHP projects. func GenerateDockerignore(dir string) string { var sb strings.Builder sb.WriteString("# Git\n") sb.WriteString(".git\n") sb.WriteString(".gitignore\n") sb.WriteString(".gitattributes\n\n") sb.WriteString("# Node\n") sb.WriteString("node_modules\n\n") sb.WriteString("# Development\n") sb.WriteString(".env\n") sb.WriteString(".env.local\n") sb.WriteString(".env.*.local\n") sb.WriteString("*.log\n") sb.WriteString(".phpunit.result.cache\n") sb.WriteString("phpunit.xml\n") sb.WriteString(".php-cs-fixer.cache\n") sb.WriteString("phpstan.neon\n\n") sb.WriteString("# IDE\n") sb.WriteString(".idea\n") sb.WriteString(".vscode\n") sb.WriteString("*.swp\n") sb.WriteString("*.swo\n\n") sb.WriteString("# Laravel specific\n") sb.WriteString("storage/app/*\n") sb.WriteString("storage/logs/*\n") sb.WriteString("storage/framework/cache/*\n") sb.WriteString("storage/framework/sessions/*\n") sb.WriteString("storage/framework/views/*\n") sb.WriteString("bootstrap/cache/*\n\n") sb.WriteString("# Build artifacts\n") sb.WriteString("public/hot\n") sb.WriteString("public/storage\n") sb.WriteString("vendor\n\n") sb.WriteString("# Docker\n") sb.WriteString("Dockerfile*\n") sb.WriteString("docker-compose*.yml\n") sb.WriteString(".dockerignore\n\n") sb.WriteString("# Documentation\n") sb.WriteString("README.md\n") sb.WriteString("CHANGELOG.md\n") sb.WriteString("docs\n") return sb.String() }