367 lines
12 KiB
Markdown
367 lines
12 KiB
Markdown
---
|
|
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.
|