php/docs/architecture.md

368 lines
12 KiB
Markdown
Raw Permalink Normal View History

---
title: Architecture
description: 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 a `php` parent (for the
multi-purpose `core` binary where PHP is one of many command groups).
- **`AddPHPRootCommands(root)`** -- adds commands directly to the root (for the
standalone `core-php` binary where `dev`, `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 `cd`s into the
active package directory before execution.
The standalone binary is minimal:
```go
// 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:
1. **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.
2. **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:
1. If the URL maps to a directory, rewrite to `{dir}/index.php`.
2. If the file does not exist and the URL does not end in `.php`, rewrite to
`/index.php` (front controller).
3. Non-PHP files that exist on disc are served directly via `http.ServeFile`.
4. Everything else is passed to `frankenphp.ServeHTTP`.
### Initialisation
```go
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.
```go
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`:
```go
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:
1. Detects which services are needed (via `DetectServices`).
2. Filters out services disabled by flags (`--no-vite`, `--no-horizon`, etc.).
3. Creates concrete service instances.
4. Starts all services, rolling back if any fail.
5. Provides unified log streaming (round-robin multiplexing from all service log files).
6. Stops services in reverse order on shutdown.
### Service Interface
All managed services implement:
```go
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 `SysProcAttr` for 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`:
1. **PHP version** -- extracted from `composer.json`'s `require.php` constraint.
2. **Extensions** -- inferred from package dependencies (e.g., `laravel/horizon`
implies `redis` and `pcntl`; `intervention/image` implies `gd`).
3. **Frontend assets** -- if `package.json` has a `build` script, a Node.js
build stage is prepended.
4. **Base image** -- `dunglas/frankenphp` with 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/` and `bootstrap/cache/`.
- Health check via `curl -f http://localhost/up`.
- Octane start command if `laravel/octane` is 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`** -- structured `CIResult` JSON 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 via `gh 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:
```go
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
1. Load config from `.env` or environment.
2. Resolve the app ID for the target environment (production or staging).
3. Trigger deployment via the Coolify API.
4. If `--wait` is set, poll every 5 seconds (up to 10 minutes) until the
deployment reaches a terminal state.
5. 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:
```yaml
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](https://github.com/FiloSottile/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()`:
```go
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.