6.5 KiB
| title | description |
|---|---|
| Development | How to build, test, and contribute to core/php. |
Development
This guide covers building the core-php binary, running the test suite, and
contributing to the project.
Prerequisites
- Go 1.26+ (the module uses Go 1.26 features)
- CGO toolchain (optional, required only for FrankenPHP embedding)
- Docker (for container build/serve commands)
- mkcert (optional, for local SSL certificates)
- PHP 8.3+ with Composer (for the PHP side of the project)
- Node.js 20+ (optional, for frontend asset building)
Building
Standard build (no CGO)
The default build produces a binary without FrankenPHP embedding. The embedded
FrankenPHP commands (serve:embedded, exec) are excluded.
# Using the core CLI
core build
# Using go directly
go build -trimpath -ldflags="-s -w" -o bin/core-php ./cmd/core-php
Build configuration lives in .core/build.yaml:
project:
name: core-php
main: ./cmd/core-php
binary: core-php
build:
cgo: false
flags:
- -trimpath
ldflags:
- -s
- -w
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: arm64
- os: windows
arch: amd64
CGO build (with FrankenPHP)
To include the embedded FrankenPHP handler, enable CGO:
CGO_ENABLED=1 go build -trimpath -o bin/core-php ./cmd/core-php
This pulls in github.com/dunglas/frankenphp and links against the PHP C
library. The resulting binary can serve Laravel applications without a separate
PHP installation.
Running Tests
# All Go tests
core go test
# -- or --
go test ./...
# Single test
core go test --run TestDetectServices
# -- or --
go test -run TestDetectServices ./...
# With race detector
go test -race ./...
# Coverage
core go cov
core go cov --open # Opens HTML report
Test Conventions
Tests follow the _Good, _Bad, _Ugly suffix pattern from the Core
framework:
_Good-- happy path, expected to succeed._Bad-- expected error conditions, verifying error handling._Ugly-- edge cases, panics, unusual inputs.
Mock Filesystem
Tests that exercise detection, Dockerfile generation, or package management use
a mock io.Medium to avoid filesystem side effects:
func TestDetectServices_Good(t *testing.T) {
mock := io.NewMockMedium()
mock.WriteFile("artisan", "")
mock.WriteFile("composer.json", `{"require":{"laravel/framework":"^11.0"}}`)
mock.WriteFile("vite.config.js", "")
php.SetMedium(mock)
defer php.SetMedium(io.Local)
services := php.DetectServices(".")
assert.Contains(t, services, php.ServiceFrankenPHP)
assert.Contains(t, services, php.ServiceVite)
}
Test Files
| File | Covers |
|---|---|
php_test.go |
DevServer lifecycle, service filtering, options |
container_test.go |
Docker build, LinuxKit build, serve options |
detect_test.go |
Project detection, service detection, package manager detection |
dockerfile_test.go |
Dockerfile generation, PHP extension detection, version extraction |
deploy_test.go |
Deployment flow, rollback, status checking |
deploy_internal_test.go |
Internal deployment helpers |
coolify_test.go |
Coolify API client (HTTP mocking) |
packages_test.go |
Package linking, unlinking, listing |
services_test.go |
Service interface, base service, start/stop |
services_extended_test.go |
Extended service scenarios |
ssl_test.go |
SSL certificate paths, existence checking |
ssl_extended_test.go |
Extended SSL scenarios |
Code Quality
# Format Go code
core go fmt
# Vet
core go vet
# Lint
core go lint
# Full QA (fmt + vet + lint + test)
core go qa
# Full QA with race detection, vulnerability scan, security checks
core go qa full
Project Structure
forge.lthn.ai/core/php/
cmd/
core-php/
main.go # Binary entry point
locales/
*.json # Internationalised CLI strings
docker/
docker-compose.prod.yml
stubs/ # Template stubs
config/ # PHP configuration templates
src/ # PHP framework source (separate from Go code)
tests/ # PHP tests
docs/ # Documentation (this directory)
.core/
build.yaml # Build configuration
*.go # Go source (flat layout, single package)
The Go code uses a flat package layout -- all .go files are in the root
php package. This keeps imports simple: import php "forge.lthn.ai/core/php".
Adding a New Command
- Create a new file
cmd_mycommand.go. - Define the registration function:
func addPHPMyCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "mycommand",
Short: i18n.T("cmd.php.mycommand.short"),
RunE: func(cmd *cli.Command, args []string) error {
// Implementation
return nil
},
}
parent.AddCommand(cmd)
}
- Register it in
cmd.goinside bothAddPHPCommandsandAddPHPRootCommands:
addPHPMyCommand(phpCmd) // or root, for standalone binary
- Add the i18n key to
locales/en.json.
Adding a New Service
- Define the service struct in
services.go, embeddingbaseService:
type MyService struct {
baseService
}
func NewMyService(dir string) *MyService {
return &MyService{
baseService: baseService{
name: "MyService",
port: 9999,
dir: dir,
},
}
}
func (s *MyService) Start(ctx context.Context) error {
return s.startProcess(ctx, "my-binary", []string{"--flag"}, nil)
}
func (s *MyService) Stop() error {
return s.stopProcess()
}
- Add a
DetectedServiceconstant indetect.go:
const ServiceMyService DetectedService = "myservice"
-
Add detection logic in
DetectServices(). -
Add a case in
DevServer.Start()inphp.go.
Internationalisation
All user-facing strings use i18n.T() keys rather than hardcoded English.
Locale files live in locales/ and are embedded via //go:embed:
//go:embed locales/*.json
var localeFS embed.FS
func init() {
i18n.RegisterLocales(localeFS, "locales")
}
When adding new commands or messages, add the corresponding keys to the locale files.
Contributing
- Follow UK English conventions: colour, organisation, centre.
- All code is licenced under EUPL-1.2.
- Run
core go qabefore submitting changes. - Use conventional commits:
type(scope): description. - Include
Co-Authored-By: Virgil <virgil@lethean.io>if pair-programming with the AI agent.