12 KiB
| title | description |
|---|---|
| Architecture | Internal design of core/php -- FrankenPHP handler, service orchestration, project detection, CI pipeline, deployment, and native bridge. |
Architecture
This document explains how the Go code in forge.lthn.ai/core/php is structured
and how the major subsystems interact.
Command Registration
The module exposes two entry points for command registration:
AddPHPCommands(root)-- adds commands under aphpparent (for the multi-purposecorebinary where PHP is one of many command groups).AddPHPRootCommands(root)-- adds commands directly to the root (for the standalonecore-phpbinary wheredev,build, etc. are top-level).
Both paths register the same set of commands and share workspace-aware
PersistentPreRunE logic that detects .core/workspace.yaml and cds into the
active package directory before execution.
The standalone binary is minimal:
// cmd/core-php/main.go
func main() {
cli.Main(
cli.WithCommands("php", php.AddPHPRootCommands),
)
}
Command Tree
core-php (or core php)
dev Start all detected services (FrankenPHP, Vite, Horizon, Reverb, Redis)
logs Stream unified or per-service logs
stop Stop all running services
status Show project info and service detection
ssl Setup mkcert SSL certificates
build Build Docker or LinuxKit image
serve Run a production Docker container
shell <container> Open a shell in a running container
ci Run full QA pipeline (test, stan, psalm, fmt, audit, security)
packages
link <paths> Add Composer path repositories for local development
unlink <names> Remove path repositories
update [pkgs] Run composer update
list List linked packages
deploy Trigger Coolify deployment
deploy:status Check deployment status
deploy:rollback Rollback to a previous deployment
deploy:list List recent deployments
serve:embedded (CGO only) Serve via embedded FrankenPHP runtime
exec <cmd> (CGO only) Execute artisan via FrankenPHP
FrankenPHP Handler (CGO)
Files: handler.go, cmd_serve_frankenphp.go, env.go, extract.go
Build tag: //go:build cgo
The Handler struct implements http.Handler and delegates all PHP processing
to the FrankenPHP C library. It supports two modes:
- Octane worker mode -- Laravel stays booted in memory across requests. Workers are persistent PHP processes that handle requests without re-bootstrapping. This yields sub-millisecond response times.
- Standard mode -- each request boots the PHP application from scratch. Used as a fallback when Octane is not installed.
Request Routing
Handler.ServeHTTP implements a try-files pattern similar to Caddy/Nginx:
- If the URL maps to a directory, rewrite to
{dir}/index.php. - If the file does not exist and the URL does not end in
.php, rewrite to/index.php(front controller). - Non-PHP files that exist on disc are served directly via
http.ServeFile. - Everything else is passed to
frankenphp.ServeHTTP.
Initialisation
handler, cleanup, err := php.NewHandler(laravelRoot, php.HandlerConfig{
NumThreads: 4,
NumWorkers: 2,
PHPIni: map[string]string{
"display_errors": "Off",
"opcache.enable": "1",
},
})
defer cleanup()
NewHandler tries to initialise FrankenPHP with workers first. If
vendor/laravel/octane/bin/frankenphp-worker.php exists, it passes the worker
script to frankenphp.Init. If that fails, it falls back to standard mode.
Embedded Applications
Extract() copies an embed.FS-packaged Laravel application to a temporary
directory so that FrankenPHP can access real filesystem paths.
PrepareRuntimeEnvironment() then creates persistent data directories
(~/Library/Application Support/{app} on macOS, ~/.local/share/{app} on
Linux), generates a .env file with an auto-generated APP_KEY, symlinks
storage/ to the persistent location, and creates an empty SQLite database.
Native Bridge
File: bridge.go
The bridge is a localhost-only HTTP server that allows PHP code to call back into
Go. This is needed because Livewire renders server-side in PHP and cannot call
Wails bindings (window.go.*) directly.
bridge, err := php.NewBridge(myHandler)
// PHP can now POST to http://127.0.0.1:{bridge.Port()}/bridge/call
The bridge exposes two endpoints:
| Method | Path | Purpose |
|---|---|---|
| GET | /bridge/health |
Health check (returns {"status":"ok"}) |
| POST | /bridge/call |
Invoke a named method with JSON arguments |
The host application implements BridgeHandler:
type BridgeHandler interface {
HandleBridgeCall(method string, args json.RawMessage) (any, error)
}
The bridge port is injected into Laravel's .env as NATIVE_BRIDGE_URL.
Service Orchestration
Files: php.go, services.go, services_unix.go, services_windows.go
DevServer
DevServer manages the lifecycle of all development services. It:
- Detects which services are needed (via
DetectServices). - Filters out services disabled by flags (
--no-vite,--no-horizon, etc.). - Creates concrete service instances.
- Starts all services, rolling back if any fail.
- Provides unified log streaming (round-robin multiplexing from all service log files).
- Stops services in reverse order on shutdown.
Service Interface
All managed services implement:
type Service interface {
Name() string
Start(ctx context.Context) error
Stop() error
Logs(follow bool) (io.ReadCloser, error)
Status() ServiceStatus
}
Concrete Services
| Service | Binary | Default Port | Notes |
|---|---|---|---|
FrankenPHPService |
php artisan octane:start --server=frankenphp |
8000 | HTTPS via mkcert certificates |
ViteService |
npm/pnpm/yarn/bun run dev |
5173 | Auto-detects package manager |
HorizonService |
php artisan horizon |
-- | Uses horizon:terminate for graceful stop |
ReverbService |
php artisan reverb:start |
8080 | WebSocket server |
RedisService |
redis-server |
6379 | Optional config file support |
All services inherit from baseService, which handles:
- Process creation with platform-specific
SysProcAttrfor clean shutdown. - Log file creation under
.core/logs/. - Background process monitoring.
- Graceful stop with SIGTERM, then SIGKILL after 5 seconds.
Project Detection
File: detect.go
The detection system inspects the filesystem to determine project capabilities:
| Function | Checks |
|---|---|
IsLaravelProject(dir) |
artisan exists and composer.json requires laravel/framework |
IsFrankenPHPProject(dir) |
laravel/octane in composer.json, config/octane.php mentions frankenphp |
IsPHPProject(dir) |
composer.json exists |
DetectServices(dir) |
Checks for Vite configs, Horizon config, Reverb config, Redis in .env |
DetectPackageManager(dir) |
Inspects lock files: bun.lockb, pnpm-lock.yaml, yarn.lock, package-lock.json |
GetLaravelAppName(dir) |
Reads APP_NAME from .env |
GetLaravelAppURL(dir) |
Reads APP_URL from .env |
Dockerfile Generation
File: dockerfile.go
GenerateDockerfile(dir) produces a multi-stage Dockerfile by analysing
composer.json:
- PHP version -- extracted from
composer.json'srequire.phpconstraint. - Extensions -- inferred from package dependencies (e.g.,
laravel/horizonimpliesredisandpcntl;intervention/imageimpliesgd). - Frontend assets -- if
package.jsonhas abuildscript, a Node.js build stage is prepended. - Base image --
dunglas/frankenphpwith Alpine variant by default.
The generated Dockerfile includes:
- Multi-stage build for frontend assets (Node 20 Alpine).
- Composer dependency installation with layer caching.
- Laravel config/route/view caching.
- Correct permissions for
storage/andbootstrap/cache/. - Health check via
curl -f http://localhost/up. - Octane start command if
laravel/octaneis detected.
CI Pipeline
File: cmd_ci.go, quality.go, testing.go
The ci command runs six checks in sequence:
| Check | Tool | SARIF Support |
|---|---|---|
test |
Pest or PHPUnit (auto-detected) | No |
stan |
PHPStan or Larastan (auto-detected) | Yes |
psalm |
Psalm (skipped if not configured) | Yes |
fmt |
Laravel Pint (check-only mode) | No |
audit |
composer audit + npm audit |
No |
security |
.env and filesystem security checks |
No |
Output Formats
- Default -- coloured terminal table with per-check status icons.
--json-- structuredCIResultJSON with per-check details.--summary-- Markdown table suitable for PR comments.--sarif-- SARIF files for stan/psalm, uploadable to GitHub Security.--upload-sarif-- uploads SARIF files viagh api.
Failure Threshold
The --fail-on flag controls when the pipeline returns a non-zero exit code:
| Value | Fails On |
|---|---|
critical |
Only if issues with Issues > 0 |
high / error (default) |
Any check with status failed |
warning |
Any check with status failed or warning |
QA Pipeline Stages
The quality.go file also defines a broader QA pipeline (QAOptions) with
three stages:
- Quick --
audit,fmt,stan - Standard --
psalm(if configured),test - Full --
rector(if configured),infection(if configured)
Deployment (Coolify)
Files: deploy.go, coolify.go
Configuration
Coolify credentials are loaded from environment variables or .env:
COOLIFY_URL=https://coolify.example.com
COOLIFY_TOKEN=your-api-token
COOLIFY_APP_ID=app-uuid
COOLIFY_STAGING_APP_ID=staging-app-uuid (optional)
Environment variables take precedence over .env values.
CoolifyClient
The CoolifyClient wraps the Coolify REST API:
client := php.NewCoolifyClient(baseURL, token)
deployment, err := client.TriggerDeploy(ctx, appID, force)
deployment, err := client.GetDeployment(ctx, appID, deploymentID)
deployments, err := client.ListDeployments(ctx, appID, limit)
deployment, err := client.Rollback(ctx, appID, deploymentID)
app, err := client.GetApp(ctx, appID)
Deployment Flow
- Load config from
.envor environment. - Resolve the app ID for the target environment (production or staging).
- Trigger deployment via the Coolify API.
- If
--waitis set, poll every 5 seconds (up to 10 minutes) until the deployment reaches a terminal state. - Print deployment status with coloured output.
Rollback
If no specific deployment ID is provided, Rollback() fetches the 10 most
recent deployments, skips the current one, and rolls back to the last
successful deployment.
Workspace Support
File: workspace.go
For multi-package repositories, a .core/workspace.yaml file at the workspace
root can set an active package:
version: 1
active: core-tenant
packages_dir: ./packages
When present, the PersistentPreRunE hook automatically changes the working
directory to the active package before command execution. The workspace root is
found by walking up the directory tree.
SSL Certificates
File: ssl.go
The ssl command and --https flag use mkcert
to generate locally-trusted SSL certificates. Certificates are stored in
~/.core/ssl/ by default.
The SetupSSLIfNeeded() function is idempotent: it checks for existing
certificates before generating new ones. Generated certificates cover the
domain, localhost, 127.0.0.1, and ::1.
Filesystem Abstraction
The module uses io.Medium from forge.lthn.ai/core/go-io for all filesystem
operations. The default medium is io.Local (real filesystem), but tests can
inject a mock medium via SetMedium():
php.SetMedium(myMockMedium)
defer php.SetMedium(io.Local)
This allows the detection, Dockerfile generation, package management, and security check code to be tested without touching the real filesystem.